Skip to content

Customer subscription duration reward modifier#3461

Merged
steven-tey merged 13 commits intomainfrom
customer-subscription-reward-modifier
Feb 13, 2026
Merged

Customer subscription duration reward modifier#3461
steven-tey merged 13 commits intomainfrom
customer-subscription-reward-modifier

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Feb 12, 2026

Summary by CodeRabbit

  • New Features

    • Added subscription-duration tracking for customers; rewards now consider subscription months when evaluating commissions.
    • Subscription duration displayed in human-friendly month format across partner program UI; condition labels improved for clearer display.
  • Tests

    • Added tests validating subscription-duration reward conditions (matching and non-matching scenarios).

@vercel
Copy link
Contributor

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Feb 13, 2026 4:32am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a customer attribute subscriptionDurationMonths to reward schemas and tests, threads subscriptionDurationMonths into partner-commission evaluation (including a re-evaluation using the first commission date), updates UI formatting for subscription duration, and adjusts a couple of commission-context payload shapes/formatting sites.

Changes

Cohort / File(s) Summary
Schema & Tests
apps/web/lib/zod/schemas/rewards.ts, apps/web/tests/rewards/reward-conditions.test.ts
Add subscriptionDurationMonths (number, nullish) to rewardContextSchema and REWARD_CONDITIONS customer attributes; add tests validating subscriptionDurationMonths matching logic (12 and 6 months).
Commission Evaluation Logic
apps/web/lib/partners/create-partner-commission.ts
Fetch first commission for lead/sale when customerId exists, derive subscriptionDurationMonths from first commission date, inject into reward context, and re-run reward determination using augmented context; replace prior monthsDifference usage.
API Callsites / Context Formatting
apps/web/lib/api/conversions/track-sale.ts, apps/web/lib/actions/partners/create-manual-commission.ts
track-sale.ts: pass source and sale.productId as possibly undefined (removed non-null/assert cast). create-manual-commission.ts: context objects reformatted to multiline (no behavioral change).
UI Formatting & Display
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx, apps/web/ui/partners/rewards/rewards-logic.tsx
Add formatSubscriptionDuration (date-fns), render subscriptionDurationMonths with human-readable formatting in reward condition UI, and append " months" suffix in condition badge for the attribute.
Misc / Manifest
manifest_file, package.json
Minor manifest/package metadata tweaks (non-functional in diff).

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/API
    participant TrackSale as TrackSale (apps/web/lib/api/conversions/track-sale.ts)
    participant CommAction as ManualCommission (apps/web/lib/actions/partners/create-manual-commission.ts)
    participant CommEval as CommEval (apps/web/lib/partners/create-partner-commission.ts)
    participant RewardEngine as RewardEngine (determinePartnerReward)
    participant DB as Database

    Client->>TrackSale: send sale event (metadata, source?)
    TrackSale->>CommEval: createPartnerCommission(context with productId?, source?)
    CommEval->>RewardEngine: determinePartnerReward(initial context)
    RewardEngine-->>CommEval: reward (or null)
    CommEval->>DB: fetch first commission (if customerId & lead/sale)
    DB-->>CommEval: firstCommission.createdAt
    CommEval->>CommEval: compute subscriptionDurationMonths
    CommEval->>RewardEngine: determinePartnerReward(augmented context + subscriptionDurationMonths)
    RewardEngine-->>CommEval: updated reward (or null)
    alt reward valid
        CommEval->>DB: persist commission
    else reward null
        CommEval-->>CommEval: return commission: null
    end
    CommAction->>CommEval: manual create uses same context shape
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

"🐰 I hopped through schemas, months now told,
Re-ran the reward machine so data's not cold.
From track to UI the duration gleams,
Tests nod softly, commissions dream. 🥕📅"

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Customer subscription duration reward modifier' clearly and concisely summarizes the main change: adding a subscription duration-based reward condition feature for customers.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch customer-subscription-reward-modifier

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
apps/web/lib/partners/create-partner-commission.ts (1)

234-239: Minor: subscriptionDurationMonths is recomputed redundantly.

This value is already computed at line 119 from the same firstCommission.createdAt. Since the earlier computation is block-scoped (lines 102–130), it's not accessible here, but the two new Date() calls will yield a nearly identical result. Consider hoisting the variable to avoid the duplication.

Suggested refactor

Hoist the subscriptionDurationMonths computation (or firstCommission.createdAt) to a scope visible to both usages, e.g.:

   // after line 117 (inside the existing if block)
-      const subscriptionDurationMonths = firstCommission
-        ? differenceInMonths(new Date(), firstCommission.createdAt)
-        : 0;
+  // Hoist this to the outer else block scope

Then at the maxDuration check (line 234), reuse the already-computed value rather than calling differenceInMonths again.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)

447-454: ⚠️ Potential issue | 🟡 Minor

Missing firstSaleAt in the new-events sale commission context.

The useExistingEvents path (Line 295) includes customer.firstSaleAt in the context, but this path does not. Without it, subscriptionDuration will always evaluate to 0 for manually created commissions via new events, even if the customer has been subscribed for months.

Proposed fix
             context: {
-              customer: { country: customer.country },
+              customer: {
+                country: customer.country,
+                firstSaleAt: customer.firstSaleAt,
+              },
               sale: { productId },
             },
🤖 Fix all issues with AI agents
In `@apps/web/ui/partners/program-reward-modifiers-tooltip.tsx`:
- Around line 231-235: formatSubscriptionDuration can return an empty string
when v is 0 or NaN because formatDuration returns "" for all-zero parts; to fix,
coerce and guard v at the top (e.g., v = Number(v); if (!Number.isFinite(v) || v
<= 0) return "0 months") or otherwise compute parts and call formatDuration,
then if the result is falsy return a defensive fallback like "0 months". Update
the function formatSubscriptionDuration to normalize v and return the fallback
when formatDuration(...) yields an empty string.
🧹 Nitpick comments (3)
apps/web/tests/rewards/reward-conditions.test.ts (1)

1663-1728: Consider adding a test for null subscriptionDuration.

The new tests cover the happy path for subscriptionDuration with greater_than_or_equal, but there's no test for when subscriptionDuration is null (which would occur when firstSaleAt is null — a new customer's first sale). In determine-partner-reward.ts, the fallback produces 0 months, but testing this edge case in the condition evaluator would increase confidence.

apps/web/lib/partners/determine-partner-reward.ts (1)

55-61: subscriptionDuration is computed for all event types, not just sales.

This is harmless since subscriptionDuration is only available as a reward condition attribute for the sale event type (per the schema in rewards.ts). However, for click and lead events, firstSaleAt will always be absent, causing an unnecessary differenceInMonths(new Date(), new Date()) call.

Not a blocker — just a minor observation if you want to guard it:

 context = {
   ...context,
   partner: { ... },
-  customer: {
+  customer: event === EventType.sale ? {
     ...context?.customer,
     subscriptionDuration: differenceInMonths(
       new Date(),
       context?.customer?.firstSaleAt ?? new Date(),
     ),
-  },
+  } : context?.customer,
 };
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

175-220: Deeply nested ternary — acknowledged by TODO, but consider extracting early.

The condition/value rendering logic is now 4+ levels of nested ternaries, which is quite difficult to follow and modify. Since you've already noted the TODO on lines 122-123, consider extracting the value-formatting logic into a small helper (e.g., formatConditionValue(condition, attribute)) sooner rather than later — it would make the next change to this block much less error-prone.

@steven-tey
Copy link
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 12, 2026

@steven-tey: I'll conduct a full review. Based on my analysis, this PR does not introduce breaking changes:

Breaking Changes Analysis

✅ Non-Breaking:

  1. Schema Changes - All new fields are optional (.nullish()):

    • customer.firstSaleAt (date)
    • customer.subscriptionDuration (number)
    • Existing reward conditions continue to work without modification
  2. Context Enhancements - Additive only:

    • firstSaleAt added to customer context in webhooks and commission creation
    • No existing fields removed or modified
    • Existing code paths unaffected
  3. Backward Compatibility:

    • In determine-partner-reward.ts, subscription duration computation uses a safe fallback (new Date()) when firstSaleAt is unavailable
    • Reward modifiers without subscription duration conditions remain functional
    • UI changes are conditional and display existing attributes normally
  4. No API Signature Changes:

    • No exported function signatures modified
    • No changes to webhook interfaces
    • No changes to commission creation APIs

Key Additions

  • New reward condition attribute: subscriptionDuration for customer entity
  • Computed duration based on months between firstSaleAt and current date
  • Human-friendly formatting in UI using date-fns formatDuration
  • Comprehensive test coverage for new condition

The changes are purely additive and maintain full backward compatibility with existing partner programs and reward configurations.

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/lib/actions/partners/create-manual-commission.ts`:
- Around line 295-302: The "new events" branch that builds the context for
partner reward evaluation is missing customer.firstSaleAt, causing
determine-partner-reward.ts to compute 0 months; in the else branch where
context is built (currently: context: { customer: { country: customer.country },
sale: { productId } }), add firstSaleAt: customer.firstSaleAt to the customer
object so the modifier logic receives the actual firstSaleAt value; update the
context construction in create-manual-commission.ts (the new events path /
useExistingEvents false branch) to include this field.
🧹 Nitpick comments (1)
apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (1)

175-220: Deeply nested ternary rendering logic — aligns with the TODO on line 122.

The attribute-value rendering at lines 182–220 now has 5+ levels of nested ternaries after adding the subscriptionDuration branch. This is hard to follow and maintain.

Not blocking, but the TODO at line 122–123 should be addressed soon — consider extracting a formatConditionValue(condition, attribute) helper.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/web/ui/partners/program-reward-modifiers-tooltip.tsx`:
- Around line 191-196: The array path for subscriptionDurationMonths doesn't
coerce items to numbers before formatting; update the branch that handles
Array.isArray(condition.value) so it first converts each element to a Number
(e.g., map(v => Number(v))) and then calls formatSubscriptionDuration on each
numeric value before joining, keeping the scalar path using
Number(condition.value) unchanged; this ensures formatSubscriptionDuration
always receives numeric months and avoids lexicographic comparisons.
- Around line 175-180: The displayed attribute label uses a hardcoded ternary
fallback for "subscriptionDurationMonths" which is unnecessary because the
attribute lookup populates attribute.label; update the JSX expression to use
only attribute?.label ?? condition.attribute (then call .toLowerCase()) instead
of the current conditional, i.e., replace the entire (attribute?.label ??
(condition.attribute === "subscriptionDurationMonths" ? "Subscription duration"
: condition.attribute))?.toLowerCase() with (attribute?.label ??
condition.attribute)?.toLowerCase() so the registered REWARD_CONDITIONS labels
are used.

@steven-tey
Copy link
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

@steven-tey: I provided a breaking changes analysis earlier in this thread. To summarize: this PR does not introduce breaking changes - all new fields are optional (.nullish()), changes are purely additive, and there are safe fallbacks in place.

However, I'll conduct a fresh full review as requested to ensure nothing was missed.


🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 3239
File: apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts:265-273
Timestamp: 2025-12-30T16:17:49.480Z
Learning: In the Dub partner/rewards system, click rewards are always flat amounts specified in `amountInCents`. They never use percentage-based amounts (`amountInPercentage`) or duration fields (`maxDuration`), so when applying modifiers to click rewards (e.g., in resolveClickRewardAmount), only `amountInCents` needs to be transferred from the matched condition.
✅ Actions performed

Full review triggered.

@steven-tey steven-tey merged commit 1fe8a3f into main Feb 13, 2026
10 checks passed
@steven-tey steven-tey deleted the customer-subscription-reward-modifier branch February 13, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants